https://github.com/howarder3/ironman2021_PyQt5_photoshop/tree/main/day28-30_final_project
我們接下來的討論,會基於讀者已經先讀過我 day5 文章 的架構下去進行程式設計
如果還不清楚我程式設計的邏輯 (UI.py、controller.py、start.py 分別在幹麻)
建議先閱讀 day5 文章後再來閱讀此文。
https://www.wongwonggoods.com/python/pyqt5-5/
完整版請參考:【PyQt5】Day 28 final project – 1 / 來搞一個自己的 photoshop 吧!UI 篇 + 純程式架構篇 (結合 PyQt + OpenCV)
昨天我們討論到了我們是如何設計程式的程式架構,
以大概念來說,我們主軸還是圍繞在
三大面向,而 UI 我們已經透過 Qt desinger 設定完成,
而 start 沒什麼好說。
我們開始著重討論 controller 的細節。
我們選擇獨立「圖片本身」與「圖片處理方法」,
我們想避免把所有圖片的功能全部都做在我們的圖像中心 (image center) 裡面,
這樣會變成一個超級巨大的 class (又名為 god class),
功能太多之後要維護一個特定功能太難了,所以我們才獨立「圖像處理方法」進行操作。
這部分是套用 design pattern 的設計原則 (使用 Interface Segregation Principle(ISP) 介面隔離原則)
我們可以把介面分離出來,更方便之後功能的維護。
套用 design pattern 的 Interface Segregation Principle(ISP) 介面隔離原則後,
我們把「修改圖片的方法」這個介面獨立出來,更方便我們維護「圖片修改」的部分。
而繼承的部分,從變更圖片的「所有共通方法 -> 滑條類方法/筆類方法 -> 各項細節方法」。
我們所有關於圖像的處理都在這邊,注意因為我們把「變化方法」丟出去做成介面了,
所以這裡只有「顯示相關」不包含「修改」。
因此這部分被簡化過,我們有:
而 update_img, set_zoom_value 是給外部呼叫的,作為 trigger 我們的 image_center 進行更新。
class image_center(object):
def __init__(self, img_path, ui):
self.img_path = img_path
self.ui = ui
self.label_mouse_controller = label_mouse_controller(self)
self.zoom_value = 1
self.read_file_and_init()
def read_file_and_init(self):
try:
self.origin_img = opencv_engine.read_image(self.img_path) # if cancel, no error !!!!
self.origin_img_height, self.origin_img_width, self.origin_img_channel = self.origin_img.shape # need this to make error !!!
except:
self.origin_img = opencv_engine.read_image('./demo_materials/sad.png')
self.origin_img_height, self.origin_img_width, self.origin_img_channel = self.origin_img.shape
self.display_img = np.copy(self.origin_img) # make a clone
self.__update_label_img()
def update_img(self, img):
self.display_img = img # default = not change, like zoom
self.__update_label_img()
def set_zoom_value(self, value):
self.zoom_value = value
def __update_img_zoom(self):
qpixmap_height = self.origin_img_height * self.zoom_value
self.qpixmap = self.qpixmap.scaledToHeight(qpixmap_height)
def __update_label_img(self):
bytesPerline = 3 * self.origin_img_width
qimg = QImage(self.display_img, self.origin_img_width, self.origin_img_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
self.qpixmap = QPixmap.fromImage(qimg)
self.__update_img_zoom()
self.ui.label_img.setPixmap(self.qpixmap)
self.ui.label_img.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
我們需要一個幫助我們感應「滑鼠在圖片上動作」的功能,例如之後的畫筆可能會使用到,
我們將這些功能封裝成一個 class label_mouse_controller,
當要製作畫筆類的功能時,他會協助我們完成「圖像上偵測滑鼠」的相關動作。
我們定義的功能有:
class label_mouse_controller(object):
def __init__(self, image_center):
self.image_center = image_center
self.ui = self.image_center.ui # new pointer point to self.image_center.ui
self.ui.label_img.mousePressEvent = self.mouse_press_event
self.ui.label_img.mouseReleaseEvent = self.mouse_release_event
self.ui.label_img.mouseMoveEvent = self.mouse_moving_event
def mouse_press_event(self, event):
msg = f"{event.x()=}, {event.y()=}, {event.button()=}"
x = event.x()
y = event.y()
norm_x = x/self.image_center.qpixmap.width()
norm_y = y/self.image_center.qpixmap.height()
real_x = int(norm_x*self.image_center.origin_img_width)
real_y = int(norm_y*self.image_center.origin_img_height)
self.ui.label_click_pos.setText(f"Clicked postion = ({x}, {y})")
self.ui.label_norm_pos.setText(f"Normalized postion = ({norm_x:.3f}, {norm_y:.3f})")
self.ui.label_real_pos.setText(f"Real postion = ({real_x}, {real_y})")
def mouse_release_event(self, event):
msg = f"{event.x()=}, {event.y()=}, {event.button()=}"
def mouse_moving_event(self, event):
msg = f"{event.x()=}, {event.y()=}, {event.button()=}"
正如同我們上面所說,我們將所有的方法都包裝好,並照上圖的方式一層層的繼承下來。
分類他是畫面方法或是畫筆類方法,再個別「繼承後,進行更細部的定義」。
所有的「圖形處理」介面,基本上都會依照此介面定義,
我們先在這個做好基本的功能,更客製化的細節功能就交給孫子們去處理。
這裡只有定義:
__init__
import abc
class method_interface(abc.ABC):
@abc.abstractmethod
def __init__(self):
return NotImplemented
@abc.abstractmethod
def update_img(self):
return NotImplemented
因為時間的關係,只來得及做一半 (slider_method_interface),
我們在裡面多定義了會使用到「滑條來修改圖片」的相關功能,會使用到的介面。
而「會滑條來修改圖片」的眾多功能,就交給孩子們去做更細節的定義吧!
這裡定義了:
__init__
class slider_method_interface(method_interface):
def __init__(self, slider, label, image_center):
self.label = label
self.slider = slider
self.image_center = image_center
self.tmp_origin_img = self.image_center.display_img
self.slider.setRange(-100, 100)
self.slider.setProperty("value", 0)
self.slider.valueChanged.connect(self.setsliderlabel)
self.slider.sliderPressed.connect(self.slider_press_event)
self.slider.sliderReleased.connect(self.slider_release_event)
self.prefix = ""
# get first picture snapshot,
def slider_press_event(self):
self.tmp_origin_img = self.image_center.display_img
# final update back to image center (not necessary, for double check)
def slider_release_event(self):
img = self.setimage(self.tmp_origin_img)
self.image_center.update_img(img)
# image do the method
def setimage(self, img):
return img
@property
def getslidervalue(self):
return self.slider.value()
# trigger function, get your signal from here
def setsliderlabel(self):
self.label.setText(f"{self.prefix}{self.slider.value():+}")
self.update_img()
def update_img(self):
self.image_center.update_img(self.tmp_origin_img) # default = origin_image no change, like zoom in/out
這裡我們就來開始撰寫「與滑條相關」的各項細部功能,像是「光線、飽和度、對比度...」,
都會是在這邊實作,而因為我們已經有在上面定義好了滑條相關的方法,
這邊如果沒有必要多做修改,可以完全不用新增「滑條的處理方法」(傳入正確的變數就會自動搞定了),
只需要專注在實現「修改圖片的方法」即可。
這邊隨便舉個範例,調整光線 method_lightness:
你可能看完會很好奇,怎麼都沒有「滑條相關」的細節實作?
這就是繼承的好處,因為我們已經在「父母輩」定義好了實作方法,
而在 __init__
中直接傳入對應的參數,瞬間就實作完「滑條相關」的細節 (因為都是共通的概念)。
這邊就是這樣處理,相當的方便,又不用重寫多次滑條處理方法。
class method_lightness(slider_method_interface):
def __init__(self, slider, label, image_center):
super().__init__(slider, label, image_center)
self.prefix = "lightness: "
self.update_img()
def setimage(self, img):
return opencv_engine.modify_lightness(img, lightness=self.slider.value())
def update_img(self):
img = self.setimage(self.tmp_origin_img)
self.image_center.update_img(img)
# trigger function, get your signal from here
def setsliderlabel(self):
self.label.setText(f"{self.prefix}{self.slider.value():+}")
self.update_img()
製作一個 OpenCV 的圖像處理引擎,並把它全部包成可以直接取用的方法「@staticmethod」,
我們只在這支程式中使用「import cv2」,方便我們集中管理。
import cv2
import numpy as np
import math
class opencv_engine(object):
@staticmethod
def point_float_to_int(point):
return (int(point[0]), int(point[1]))
@staticmethod
def read_image(file_path):
return cv2.imread(file_path)
@staticmethod
def draw_point(img, point=(0, 0), color = (0, 0, 255)): # red
point = opencv_engine.point_float_to_int(point)
print(f"get {point=}")
point_size = 1
thickness = 4
return cv2.circle(img, point, point_size, color, thickness)
@staticmethod
def draw_line(img, start_point = (0, 0), end_point = (0, 0), color = (0, 255, 0)): # green
start_point = opencv_engine.point_float_to_int(start_point)
end_point = opencv_engine.point_float_to_int(end_point)
thickness = 3 # width
return cv2.line(img, start_point, end_point, color, thickness)
@staticmethod
def draw_rectangle_by_points(img, left_up=(0, 0), right_down=(0, 0), color = (0, 0, 255)): # red
left_up = opencv_engine.point_float_to_int(left_up)
right_down = opencv_engine.point_float_to_int(right_down)
thickness = 2 # 寬度 (-1 表示填滿)
return cv2.rectangle(img, left_up, right_down, color, thickness)
@staticmethod
def draw_rectangle_by_xywh(img, xywh=(0, 0, 0, 0), color = (0, 0, 255)): # red
left_up = opencv_engine.point_float_to_int((xywh[0], xywh[1]))
right_down = opencv_engine.point_float_to_int((xywh[0]+xywh[2], xywh[1]+xywh[3]))
thickness = 2 # 寬度 (-1 表示填滿)
return cv2.rectangle(img, left_up, right_down, color, thickness)
@staticmethod
def modify_lightness(img, lightness = 0): # range: -100 ~ 100
if lightness == 0: # no change
return img
# lightness 調整為 "1 +/- 幾 %"
# 圖像歸一化,且轉換為浮點型
fImg = img.astype(np.float32)
fImg = fImg / 255.0
# 顏色空間轉換 BGR -> HLS
hlsImg = cv2.cvtColor(fImg, cv2.COLOR_BGR2HLS)
hlsCopy = np.copy(hlsImg)
# 亮度調整
hlsCopy[:, :, 1] = (1 + lightness / 100.0) * hlsCopy[:, :, 1]
hlsCopy[:, :, 1][hlsCopy[:, :, 1] > 1] = 1 # 應該要介於 0~1,計算出來超過1 = 1
# 顏色空間反轉換 HLS -> BGR
result_img = cv2.cvtColor(hlsCopy, cv2.COLOR_HLS2BGR)
result_img = ((result_img * 255).astype(np.uint8))
return result_img
@staticmethod
def modify_saturation(img, saturation = 0): # range: -100 ~ 100
if saturation == 0: # no change
return img
# saturation 調整為 "1 +/- 幾 %"
# 圖像歸一化,且轉換為浮點型
fImg = img.astype(np.float32)
fImg = fImg / 255.0
# 顏色空間轉換 BGR -> HLS
hlsImg = cv2.cvtColor(fImg, cv2.COLOR_BGR2HLS)
hlsCopy = np.copy(hlsImg)
# 飽和度調整
hlsCopy[:, :, 2] = (1 + saturation / 100.0) * hlsCopy[:, :, 2]
hlsCopy[:, :, 2][hlsCopy[:, :, 2] > 1] = 1 # 應該要介於 0~1,計算出來超過1 = 1
# 顏色空間反轉換 HLS -> BGR
result_img = cv2.cvtColor(hlsCopy, cv2.COLOR_HLS2BGR)
result_img = ((result_img * 255).astype(np.uint8))
return result_img
@staticmethod
def modify_contrast_brightness(img, brightness=0 , contrast=0): # range: -100 ~ 100
if brightness == 0 and contrast == 0:
return img
B = brightness / 255.0
c = contrast / 255.0
k = math.tan((45 + 44 * c) / 180 * math.pi)
img = (img - 127.5 * (1 - B)) * k + 127.5 * (1 + B)
# 所有值必須介於 0~255 之間,超過255 = 255,小於 0 = 0
img = np.clip(img, 0, 255).astype(np.uint8)
return img
把上面落落長的東西都實作完,並 debug 完,
終於暫時有了現在的作品!
但現在還有一些效能問題要處理,例如說載入太大解析度的圖片時,
我們使用「滑條功能」,因為會產生「連續的變化計算」,
太大解析度的電腦計算速度可能會跟不上。
目前這部分可能還需要想想怎麼樣優化會更好XD
(或者直接縮放後以低解析度作處理XD,紀錄「方法步驟」後,最後存檔才重新實現這些步驟。)
這個是我下一篇想要談的XD,有沒有機會把「方法」當作一個個的「物件」,保存進一個 queue 呢?
★ 本文也同步發於我的個人網站(會有內容目錄與顯示各個小節,閱讀起來更流暢):【PyQt5】Day 29 final project - 2 / 來搞一個自己的 photoshop 吧!後段程式細節篇 (結合 PyQt + OpenCV)